Python: Add observability for MCP tool according to latest OTel 1.40.0 conventions#4698
Python: Add observability for MCP tool according to latest OTel 1.40.0 conventions#4698
Conversation
… 1.40.0 conventions Co-authored-by: TaoChenOSU <12570346+TaoChenOSU@users.noreply.github.com>
|
@copilot Address comments |
Python Test Coverage Report •
Python Unit Test Overview
|
|||||||||||||||||||||||||||||||||||
…sion from mcp.types Co-authored-by: TaoChenOSU <12570346+TaoChenOSU@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR adds OpenTelemetry (OTel) CLIENT-span instrumentation for MCP client operations in the Python core package, aligning MCP tool/prompt calls with the MCP semantic conventions (OTel 1.40.0), and updates/extends MCP GitHub PAT samples plus tests.
Changes:
- Added MCP-specific OTel attributes and a helper to create MCP CLIENT spans.
- Instrumented MCP
call_tool()andget_prompt()with CLIENT spans and error/status handling, including JSON-RPC/MCP protocol attributes. - Expanded MCP tests and added new GitHub PAT samples + updated MCP samples README.
Reviewed changes
Copilot reviewed 5 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| python/packages/core/agent_framework/observability.py | Adds MCP-related OTel attributes and a get_mcp_call_span() helper to standardize CLIENT spans. |
| python/packages/core/agent_framework/_mcp.py | Wraps MCP tool/prompt calls in CLIENT spans, injects trace context into MCP meta, and emits MCP/JSON-RPC attributes and error info. |
| python/packages/core/tests/core/test_mcp.py | Updates OTel meta propagation expectations and adds span-attribute/error behavior tests for MCP calls. |
| python/samples/02-agents/mcp/mcp_github_pat_openai_responses.py | New sample showing GitHub MCP usage via OpenAI Responses hosted MCP tool config. |
| python/samples/02-agents/mcp/mcp_github_pat_azure_chat.py | New sample showing GitHub MCP usage via local MCPStreamableHTTPTool with Azure OpenAI chat client. |
| python/samples/02-agents/mcp/README.md | Updates the sample list and environment variable prerequisites for the new GitHub PAT samples. |
|
@copilot Address the comments |
…ion, README prereqs, and AsyncClient lifecycle Co-authored-by: TaoChenOSU <12570346+TaoChenOSU@users.noreply.github.com>
Addressed all three automated review comments in 1306e5b:
|
eavanvalkenburg
left a comment
There was a problem hiding this comment.
Automated Code Review
Reviewers: 3 | Confidence: 89%
✗ Security Reliability
The diff adds OpenTelemetry span instrumentation to MCP call_tool and get_prompt methods, derives the JSON-RPC protocol version from type annotations at module load, captures a private SDK attribute (_request_id) for span metadata, and adds a new Azure OpenAI sample. The changes are generally sound from a security/reliability perspective. The main reliability concern is the module-level _JSONRPC_PROTOCOL_VERSION derivation which accesses internal mcp library type metadata and will crash with an unhandled IndexError/KeyError at import time if the library's type structure changes. There is also minor information leakage via str(ex) in span status messages, though this is low-risk as spans are typically internal observability data. The reliance on a private _request_id attribute is acknowledged in comments and handled safely with getattr defaults.
✓ Test Coverage
The diff adds OTel span instrumentation to
call_toolandget_promptin_mcp.py, a newget_mcp_call_spanhelper inobservability.py, and new MCP-specificOtelAttrenum members. Test coverage for the happy paths and several error paths (McpError, tool error result, generic exception) is solid with meaningful attribute assertions. However, there are notable gaps: no tests verify span error attributes for the ClosedResourceError reconnection paths (both reconnect failure and second-attempt failure), no test verifies thatconnect()correctly sets_mcp_protocol_version, no unit test forget_mcp_call_spanitself intest_observability.py, and no test forget_promptwithMcpError(onlyRuntimeErroris tested).
✗ Design Approach
The PR adds OTel CLIENT spans for MCP tool/prompt calls following the OTel semantic conventions, which is a reasonable approach. Two design-level issues stand out. First,
_JSONRPC_PROTOCOL_VERSIONis derived via reflection on the MCP library's Pydantic model field annotation (typing.get_args(...)[0]). This raisesIndexErrorat module import time if the MCP library ever changes that field's type away fromLiteral["2.0"]— a plausible change as the library evolves. JSON-RPC 2.0 is a stable, versioned protocol specification; the right fix is to hardcode"2.0"as a named constant, not to derive it via fragile reflection. Second, theif self._mcp_protocol_version:truthiness checks would silently skip emitting themcp.protocol.versionspan attribute when the negotiated version is the integer0(which is permitted by thestr | inttype ofInitializeResult.protocolVersion). This should beis not None. The_request_idpre-call read is a minor concern: it reads a private SDK attribute to predict the next request ID before the call is dispatched, which is racy under concurrent session usage; this is flagged as a suggestion since there is no public API alternative today.
Flagged Issues
- Module-level
_JSONRPC_PROTOCOL_VERSIONis derived viatyping.get_args(types.JSONRPCRequest.model_fields["jsonrpc"].annotation)[0]at import time. If the MCP library changes this field's annotation away fromLiteral["2.0"](e.g., to plainstr),get_argsreturns()and[0]raisesIndexError; if the field is renamed,model_fields['jsonrpc']raisesKeyError. Either crashes all code that imports_mcp. JSON-RPC 2.0 is a stable IETF specification constant—hardcode_JSONRPC_PROTOCOL_VERSION = "2.0"rather than deriving it via reflection, or at minimum wrap in try/except with a"2.0"fallback. -
if self._mcp_protocol_version:uses truthiness rather thanis not Nonein bothcall_tool(line 947) andget_prompt(line 1052). SinceInitializeResult.protocolVersionis typedstr | int, an integer version of0is falsy and would silently suppress themcp.protocol.versionspan attribute. Both occurrences should useif self._mcp_protocol_version is not None:.
Suggestions
- Consider truncating or sanitizing exception messages passed to
span.set_status()(viastr(ex)) to avoid leaking internal details (file paths, connection strings) into telemetry backends, especially for unexpectedExceptiontypes. - The f-string logger calls (e.g.,
logger.error(f"MCP connection closed...")) eagerly format the string. Prefer lazy formatting:logger.error("MCP connection closed unexpectedly after reconnection: %s", cl_ex). This is a pre-existing pattern, but new lines replicate it. - Add a test verifying that
_mcp_protocol_versionis set duringconnect()frominit_result.protocolVersion. This new behavior is untested. - Add tests for ClosedResourceError paths in
call_toolandget_promptthat verify span error attributes (e.g., reconnect failure setserror.typeto the reconnect exception type, second-attempt failure sets it toClosedResourceError). The existing reconnection tests don't usespan_exporter. - Add a test for
get_promptwhenMcpErroris raised, verifying thaterror.typeis set to the MCP error code (mirroringtest_call_tool_span_sets_error_on_mcp_error). - Consider adding a direct unit test for
get_mcp_call_spanintest_observability.pyto verify span name construction, SpanKind, and thatset_status_on_exception=False/record_exception=Falseare passed correctly. - The
_request_idpre-call capture (getattr(self.session, "_request_id", None)) reads a private SDK counter before the call is dispatched to predict the next request ID. Under concurrent session usage this is racy—another coroutine could consume the ID between the read and the send. Consider omittingjsonrpc.request.idfrom the span until the MCP SDK exposes a public API, rather than relying on an internal counter that could silently produce wrong IDs.
Automated review by eavanvalkenburg's agents
| # Derive the JSON-RPC protocol version used by the MCP library from its type annotations. | ||
| # mcp.types.JSONRPCRequest defines `jsonrpc: Literal["2.0"]`; extracting it here ensures |
There was a problem hiding this comment.
_JSONRPC_PROTOCOL_VERSION is derived via reflection on internal Pydantic model_fields metadata and typing.get_args(...)[0]. If the MCP library changes JSONRPCRequest's jsonrpc field annotation away from Literal["2.0"] (or renames the field), this raises IndexError or KeyError at import time. JSON-RPC 2.0 is a stable protocol constant—prefer hardcoding "2.0" directly, or at minimum add a try/except fallback.
| # Derive the JSON-RPC protocol version used by the MCP library from its type annotations. | |
| # mcp.types.JSONRPCRequest defines `jsonrpc: Literal["2.0"]`; extracting it here ensures | |
| _JSONRPC_PROTOCOL_VERSION: str = "2.0" |
| OtelAttr.TOOL_NAME: tool_name, | ||
| OtelAttr.OPERATION: OtelAttr.TOOL_EXECUTION_OPERATION, | ||
| OtelAttr.JSONRPC_PROTOCOL_VERSION: _JSONRPC_PROTOCOL_VERSION, | ||
| } |
There was a problem hiding this comment.
Truthiness check will silently skip emitting mcp.protocol.version if the server negotiates integer version 0 (allowed by str | int). Use is not None.
| } | |
| if self._mcp_protocol_version is not None: |
| OtelAttr.JSONRPC_PROTOCOL_VERSION: _JSONRPC_PROTOCOL_VERSION, | ||
| } | ||
| if self._mcp_protocol_version: | ||
| span_attributes[OtelAttr.MCP_PROTOCOL_VERSION] = self._mcp_protocol_version |
There was a problem hiding this comment.
Same truthiness issue as in call_tool: use is not None to correctly handle a negotiated protocol version of 0.
| span_attributes[OtelAttr.MCP_PROTOCOL_VERSION] = self._mcp_protocol_version | |
| if self._mcp_protocol_version is not None: |
|
|
||
|
|
||
| @pytest.fixture | ||
| def mcp_test_server_class(): |
There was a problem hiding this comment.
The mcp_test_server_class fixture's connect() never sets _mcp_protocol_version. Consider adding a test that exercises the real connect() path (or at least verifies the assignment) to cover the new self._mcp_protocol_version = init_result.protocolVersion line.
| """get_prompt emits a CLIENT span following OTel MCP conventions.""" | ||
| from opentelemetry.trace import SpanKind | ||
|
|
||
| server = mcp_test_server_class() |
There was a problem hiding this comment.
This test verifies mcp.protocol.version is present when explicitly set, but there's no assertion that it's absent in the default case (e.g., in test_call_tool_creates_mcp_client_span). Adding assert 'mcp.protocol.version' not in span.attributes in the default-path test would confirm the conditional logic works both ways.
| assert span.attributes["error.type"] == "RuntimeError" | ||
|
|
||
|
|
||
| # endregion |
There was a problem hiding this comment.
Only RuntimeError is tested for get_prompt error span handling. Consider adding a parallel test for McpError (similar to test_call_tool_span_sets_error_on_mcp_error) to verify error.type is set to the MCP error code string rather than a class name.
Motivation and Context
MCP tool calls lacked OpenTelemetry instrumentation, making them invisible in distributed traces. This adds CLIENT spans for
tools/callandprompts/getoperations following the OTel 1.40.0 MCP semantic conventions.Description
observability.pyOtelAttrentries:MCP_METHOD_NAME,MCP_PROTOCOL_VERSION,MCP_SESSION_ID,JSONRPC_REQUEST_ID,JSONRPC_PROTOCOL_VERSION,PROMPT_NAMEget_mcp_call_span(): creates aSpanKind.CLIENTspan named{mcp.method.name} {target}(e.g.tools/call get-weather). Uses"unknown"as the explicit fallback whenmcp.method.nameis not provided._mcp.py_mcp_protocol_versionfromsession.initialize()result for use in spanscall_tool()now wraps the MCP call in a CLIENT span with required/recommended attributes (mcp.method.name,gen_ai.tool.name,gen_ai.operation.name,jsonrpc.protocol.version,jsonrpc.request.id,mcp.protocol.version); setserror.typeandERRORstatus on failures includingMcpError,ClosedResourceError, tool error results, andToolExecutionExceptionraised by a customparse_tool_results(a_span_error_setflag prevents overwriting the"ToolError"attribute already set by theresult.isErrorpath)get_prompt()instrumented the same way withprompts/get/gen_ai.prompt.namemcp.types.JSONRPCRequest's type annotation (jsonrpc: Literal["2.0"]) at module load time rather than hardcoded, ensuring it always matches what the MCP library actually usescall_toolnow owns a span,_inject_otel_into_mcp_meta()always fires inside an active span contextmcp_github_pat_azure_chat.pyAsyncClientresource leak: the client is now created withasync with AsyncClient(...) as http_clientcombined into the sameasync withstatement asMCPStreamableHTTPTool, ensuring it is always properly closedmcp/README.mdOPENAI_API_KEY/OPENAI_RESPONSES_MODEL_IDfrom the top-level prerequisites section (those only apply to the OpenAI Responses sample). Replaced with a note directing users to the per-sample prerequisite sections.Example trace structure for an agent tool call:
Contribution Checklist
📱 Kick off Copilot coding agent tasks wherever you are with GitHub Mobile, available on iOS and Android.